원본 항목: Part 1-3. 메모리 경계 계열 — 14. 범위 초과해서 읽기CWE-125repo 폴더: sd14_readoverflow/ (빌드·실행은 §3-1)
목표 시간: 1회차 12분 / 2회차 7분 / 3회차 5분
SD11~13은 전부 “쓰기(write)” 오버플로우였다.
이 항목은 “읽기(read)” 오버플로우다 — 겉보기에는 크래시하지 않고 조용히 넘어갈 수도 있어서 더 위험하다는 걸 체감한다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
- return arr[idx]; /* 상한 검사 없음 */+ if (idx < 0 || (size_t)idx >= size) return -1; /* 배열과 "길이"를 항상 같이 받는다 */+ *out = arr[idx];
배열 포인터를 넘길 때는 반드시 길이도 함께 넘긴다.
“포인터 단독 전달”이 out-of-bounds read의 근본 원인이다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 배열 인덱스 조회
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
B 문자열 배열(char*[]) 조회
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
C 2차원 배열(행렬) 조회
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
1. 요구사항서
1-A. 변형 A — 배열 인덱스 조회
정수 배열과 인덱스를 받아 해당 위치의 값을 반환한다.
인덱스가 배열 크기를 벗어나면 안전하게 실패 처리해야 한다.
함수
int read_at_index_bad(const int *arr, int idx);int read_at_index_good(const int *arr, size_t size, int idx, int *out);
공격 입력표
idx (배열 크기 3)
Bad 결과
이유
2 (정상)
정상 값 반환
유효 인덱스(0~2)
5 (범위 초과)
힙 버퍼 오버리드로 크래시
배열 뒤 힙 메모리를 읽는다 — 다른 데이터·포인터 값이 새어나갈 수 있다
1-B. 변형 B — 문자열 배열(char*[]) 조회
범위를 넘으면 포인터 값 자체가 유출될 수 있다는 점(ASLR 무력화로 이어지는 이유)을 노트에 적어본다
1-C. 변형 C — 2차원 배열(행렬) 조회
data[row][col] 형태에서 row, col양쪽 모두 범위를 검사해야 하는 이유를 확인
2. pseudo code 템플릿 — 보고 쳐서 익힌다
사용법
변형별로 아래 pseudo code 를 소스 파일에 주석만 복사한다.
코드는 주석을 보고 내가 쓴다.
다 쓰면 [ ] 를 x 로 바꾼다.
막히면 §5 모범답안(A)의 해당 단계만 본다.
2-A. read_at_index_good — Level 1 (1회차, 전부 본다)
먼저 Bad 를 그대로 쳐서 §3 의 test_bad_is_vulnerable 이 통과하는지(= 실제로 뚫리는지) 확인한다.
그다음 Good 을 쓴다.
int read_at_index_bad(const int *arr, int idx){ /* 1. return arr[idx] ← 범위 검사 없음 */}int read_at_index_good(const int *arr, size_t size, int idx, int *out){ /* [ ] 1. 범위 검사 (읽기 전) * idx < 0 또는 (size_t)idx >= size → return -1 */ /* [ ] 2. *out = arr[idx] * 값은 out 으로, 상태는 리턴으로 분리한다 */ /* [ ] 3. return 0 */}
int read_at_index_good(const int *arr, size_t size, int idx, int *out){ /* [ ] 1. 범위 검사 (읽기 전) */ /* [ ] 2. *out = arr[idx] */ /* [ ] 3. return 0 */}
2-C. read_at_index_good — Level 3 (3회차, 백지)
int read_at_index_good(const int *arr, size_t size, int idx, int *out){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - 배열과 **길이를 항상 같이** 받는다 * - 하한(`< 0`)을 빼먹지 않는다 */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
읽기 오버플로우는 크래시가 없을 때도 있다(매핑된 페이지라면). 대신 세션 토큰·키 같은 민감정보가 반환값에 실려 유출된다 — 쓰기보다 더 늦게 발견된다
함수가 값과 에러를 같은 채널로 반환(int read_at_index(...) 하나로 값과 -1 에러를 겸용)
정상 값이 우연히 -1 일 때 구분이 안 된다. 이 드릴처럼 리턴값은 상태 전용, 실제 값은 출력 인자(*out)로 분리하는 습관을 들인다
size 를 int 로 받아서 음수 크기가 들어올 가능성을 열어둠
배열 크기는 개념적으로 음수일 수 없다. size_t 로 받아 타입 자체가 음수를 표현 못 하게 한다
5. 모범답안 (변형 A)
다 치고 나서 열 것 — arr_read.h / arr_read.c
헤더 (arr_read.h)
#ifndef ARR_READ_H#define ARR_READ_H#include <stddef.h>int read_at_index_bad(const int *arr, int idx);int read_at_index_good(const int *arr, size_t size, int idx, int *out);#endif
구현 (arr_read.c)
#include "arr_read.h"int read_at_index_bad(const int *arr, int idx){ return arr[idx];}int read_at_index_good(const int *arr, size_t size, int idx, int *out){ if (idx < 0 || (size_t)idx >= size) return -1; *out = arr[idx]; return 0;}
눈여겨볼 점: read_at_index_bad 는 인자가 2개, read_at_index_good 은 4개다.
늘어난 두 개(size, int *out)가 이 항목이 요구하는 방어 그 자체다 — 길이를 동반하고, 값과 상태를 분리한다.